Files Overview
The Patient Portal Files API lets the authenticated patient list, read, upload, rename, and soft-delete files (the /me/files resource). Every endpoint is self-only: the JWT subject is the only patient whose files are visible, and renames/deletes additionally require the patient to be the original uploader.
Uploads support two modes: case attachment (supply caseId) and general document (omit caseId). General documents are stored as UserDocument rows (type GENERAL) and appear in the admin panel under Documents → User Documents, but are not returned by GET /me/files.
Endpoints
| # | Method | Path | Purpose |
|---|---|---|---|
| 1 | GET | /api/v1/users/me/files | List the patient's attachments (optionally filtered) |
| 2 | GET | /api/v1/users/me/files/:id/metadata | Get a single attachment's metadata |
| 3 | GET | /api/v1/users/me/files/:id/download | Get a short-lived signed download URL |
| 4 | POST | /api/v1/users/me/files/upload | Upload a base64-encoded file; linked to a case if caseId is provided, otherwise saved as a general document |
| 5 | PATCH | /api/v1/users/me/files/:id/metadata | Rename an attachment the patient uploaded |
| 6 | DELETE | /api/v1/users/me/files/:id | Soft-delete an attachment the patient uploaded |
Authentication
Every endpoint requires a successful /verify-otp exchange first.
| Header | Required | Description |
|---|---|---|
cv-api-key | Yes | Tenant API key. Resolves the calling organization. Missing → 400 VALIDATION_ERROR. |
Authorization | Yes | Bearer <accessToken> from POST /api/v1/users/auth/verify-otp. Missing or malformed → 401. |
Content-Type | Yes (POST/PATCH only) | Must be application/json. |
The patientPortalAuth() middleware enforces token type patient-portal, JWT/cv-api-key org-match, and that the user still exists. Any failure is collapsed to 401 VALIDATION_ERROR "Invalid or expired token".
Permission Matrix
| Action | Allowed when… |
|---|---|
| List own attachments | Always (filtered to the patient's cases in the calling org). |
| List a specific case's attachments | The case is owned by the patient (submitterId) and belongs to the calling org. |
| Read metadata for an attachment | The attachment's case is owned by the patient and belongs to the calling org, and the attachment is not soft-deleted. |
| Download an attachment | Same as metadata read. |
| Upload (case attachment) | caseId is provided, the case is owned by the patient (submitterId), and belongs to the calling org. |
| Upload (general document) | caseId is omitted. No case ownership check; file is stored as a UserDocument (type GENERAL). |
| Rename an attachment | All of the above plus the attachment's uploadedById equals the patient's userId. |
| Soft-delete an attachment | Same as rename — patient must be the original uploader. |
The 404 is intentionally uniform: "doesn't exist", "deleted", "not yours", "wrong tenant", and "you didn't upload it" all collapse to the same response so attachment ids cannot be probed.
Common Response Envelope
List responses wrap the array in data.files:
{ "status": 200, "success": true, "data": { "files": [ "..." ] } }
Single-attachment responses (metadata, download, upload, update) place the payload directly under data:
{ "status": 200, "success": true, "data": { "...": "see Attachment Object Shapes" } }
Delete returns:
{ "status": 200, "success": true, "message": "File deleted successfully" }
Error responses follow:
{ "status": 400, "success": false, "error": "<message>", "code": "<CODE>" }
Attachment Object Shapes
The shapes are not uniform across endpoints — pay attention to which fields appear where.
List item / Metadata / Update result
Used by GET /me/files, GET /me/files/:id/metadata, and PATCH /me/files/:id/metadata.
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | CaseAttachment.id. |
fileName | string | Original or last-renamed file name. |
isPHI | boolean | Defaults to false on creation. Server-controlled. |
isRestricted | boolean | Defaults to false. Server-controlled. |
caseId | string (UUID) | The case the attachment belongs to. |
uploadedBy | { id, firstName, lastName } | null | Includes the uploader's User row (staff or patient). |
createdAt | ISO-8601 datetime | Server-generated on create. |
Download result
Used by GET /me/files/:id/download.
| Field | Type | Notes |
|---|---|---|
downloadUrl | string | Short-lived signed URL into the GCS bucket (V4 read action). |
fileName | string | The current file name to use as the download filename. |
expiresIn | number | 900 (seconds). Matches the 15-minute signed-URL TTL. |
Upload result
Used by POST /me/files/upload.
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | CaseAttachment.id (case upload) or UserDocument.id (general upload). |
fileName | string | The name you supplied. |
isPHI | boolean | Always false at creation. |
isRestricted | boolean | Always false at creation. |
caseId | string (UUID) | null | Echoed from the request, or null for general uploads. |
createdAt | ISO-8601 datetime | Server-generated. |
The upload response does not include uploadedBy. If your client needs uploader info immediately after upload, follow up with GET /me/files/:id/metadata.
Allowed MIME Types
Uploads must declare a mimeType from this allowlist. Anything else fails Zod validation.
application/pdfapplication/mswordtext/csvtext/plainimage/jpegimage/pngimage/svg+xmlimage/tiffimage/webp
Server-Side Behaviors and Defaults
- Soft-delete only.
DELETEflipsisDeleted = trueand writes aCaseActivityrow of typeDELETE_ATTACHMENT. The bucket object is retained. - Activity log. The patient's
userIdis recorded asactorIdon the activity row. isPHI/isRestrictedare server-controlled. Patient uploads always start withisPHI = falseandisRestricted = false. The patient portal does not expose a way to set or change these.- Sort order on list.
createdAtdescending (newest first). - Soft-deleted rows are excluded from every read endpoint.
- Signed URLs are V4 read URLs with a 15-minute wall-clock TTL.
- Object path layout. Case attachments:
cases/<organizationId>/<caseId>/<attachmentId>/<fileName>. General documents:userDocs/<userId>/<documentId>/<fileName>. - No size cap on uploads is enforced at the application layer. App Engine / proxy limits apply transitively.
- Atomic upload semantics. A failed bucket write is followed by a soft-delete of the just-created DB row; the patient sees
500 INTERNAL_ERRORand the row will not appear in subsequent listings.
Security Properties
- Tenant isolation. Every file lookup checks
case.organizationId === req.patientOrganization.id. - Ownership isolation. Every file lookup checks
case.submitterId === req.patientUser.id. - Uploader-self gate on mutation. Rename and delete additionally require
uploadedById === userId. Staff-uploaded files are read-only to the patient. - Uniform 404. Not-found, soft-deleted, wrong-tenant, wrong-owner, and (for mutations) wrong-uploader all return the same
404 VALIDATION_ERROR"File not found". - Token type pinned. Only JWTs with
type: 'patient-portal'reach the handler. - Cross-tenant defense. The JWT's
organizationIdis verified against thecv-api-key-resolved org on every call. - Signed-URL expiry. Download URLs are short-lived (15 min);
expiresIn: 900advertises the same window. - Allowlisted MIME types. Only the nine types above are accepted on upload.
Integrator Guidance
- Refresh proactively. Refresh the access token via
/refresh-tokenbefore the 15-minute expiry. - Listing strategy. Call
GET /me/filesonce per session and refresh after upload/rename/delete. Use?caseId=when surfacing files within a single-case view. - Filtering. Use
?type=phior?type=generalto split the patient view; omit to show everything. - Upload payload. Send the file as base64 in
data. Chunked / multipart upload is not offered. - General uploads are not listed by
GET /me/files. OmittingcaseIdcreates aUserDocument, which is visible in the admin panel under Documents → User Documents but outside the scope of the/me/fileslist endpoint. - Renames are display-only. They change the visible label and the next download URL's filename, but do not relocate the underlying bucket object.
- Patient can only rename/delete their own uploads. Surface staff-uploaded attachments as read-only in the UI to avoid
404surprises. - Download UX. Re-fetch
:id/downloadon demand rather than caching the signed URL. - Treat
404as "no permission, may or may not exist". Do not display id-specific debug text.